開始實作商品管理的上傳圖片、預覽圖片以及相關功能。

在商品新增修改上有出現一個上傳圖片的功能,
我們會把上傳圖片的功能拉出來做一個小組件,
在商品修增修改時將會出此小組件。
-src
  |-app
    |-cms
       |-product
            ...
            |-list
                |-dialog-detail
                   |-dialog-detail.component.html
                   |-dialog-detail.component.css
                   |-dialog-detail.component.ts
                |-file-uploader
                   |-file-uploader.component.html
                   |-file-uploader.component.css
                   |-file-uploader.component.ts
                |-list.component.html
                |-list.component.css
                |-list.component.ts
           |-product-routing.module.ts
           |-product.component.ts
           |-product.module.ts

file-uploader.component.ts:export class FileUploaderComponent implements OnInit {
  @Input() parent: FormGroup;
  imageSrc: string = "null";
  activeColor: string = "green";
  baseColor: string = "#ccc";
  overlayColor: string = "rgba(255,255,255,0.5)";
  dragging: boolean = false;
  loaded: boolean = false;
  imageLoaded: boolean = false;
  ngOnInit() {
    if (!!this.parent) {
      this.imageSrc = this.parent.value.file;
    }
  }
  handleDragEnter() {
    this.dragging = true;
  }
  handleDragLeave() {
    this.dragging = false;
  }
  handleDrop(e) {
    e.preventDefault();
    this.dragging = false;
    this.handleInputChange(e);
  }
  handleImageLoad() {
    this.imageLoaded = true;
  }
  handleInputChange(e) {
    let file = e.dataTransfer ? 
    e.dataTransfer.files[0] : e.target.files[0];
    let pattern = /image-*/;
    let reader = new FileReader();
    if (!file.type.match(pattern)) {
      alert("invalid format");
      return;
    }
    this.loaded = false;
    reader.onload = this._handleReaderLoaded.bind(this);
    reader.readAsDataURL(file);
  }
  _handleReaderLoaded(e) {
    let reader = e.target;
    this.imageSrc = reader.result;
    this.loaded = true;
    this.parent.patchValue({
      file: this.imageSrc
    });
  }
  cancel() {
    this.imageSrc = "";
  }
}
這裡要注意的是,@Input() parent: FormGroup;,
有來自於父組件的表單綁定跟驗證!
handleInputChange(),做一些簡單的驗證格式。--
file-uploader.component.html:<div class="item-wrapper two pink" [formGroup]="parent">
  <div>
    <span>*{{ "upload_img" | translate }}</span>
  </div>
  <div class="flex straight-flex uploader-box">
    <div>
      <label
        class="uploader"
        ondragover="return false;"
        [class.loaded]="loaded"
        [style.outlineColor]="dragging ? activeColor : baseColor"
        (dragenter)="handleDragEnter()"
        (dragleave)="handleDragLeave()"
        (drop)="handleDrop($event)"
      >
        <i
          class="icon icon-upload"
          [style.color]="
            dragging
              ? imageSrc.length > 0
                ? overlayColor
                : activeColor
              : imageSrc.length > 0
              ? overlayColor
              : baseColor
          "
        ></i>
        <img [src]="imageSrc" (load)="handleImageLoad()" 
        [class.loaded]="imageLoaded" />
      </label>
    </div>
    <div>
      <input type="file" name="file" accept="image/*" 
      (change)="handleInputChange($event)" />
    </div>
    <div>
      <button (click)="cancel()">
        {{ "clear" | translate }}
      </button>
    </div>
  </div>
</div>
--
file-uploader.component.css:.uploader input {
  display: none;
}
.uploader {
  -webkit-align-items: center;
  align-items: center;
  background-color: #efefef;
  background-color: rgba(0, 0, 0, 0.02);
  cursor: pointer;
  display: -webkit-flex;
  display: flex;
  height: 150px;
  -webkit-justify-content: center;
  justify-content: center;
  outline: 1px dashed #ccc;
  position: relative;
  width: 150px;
}
.uploader img,
.uploader .icon {
  pointer-events: none;
}
.uploader,
.uploader .icon {
  -webkit-transition: all 100ms ease-in;
  -moz-transition: all 100ms ease-in;
  -ms-transition: all 100ms ease-in;
  -o-transition: all 100ms ease-in;
  transition: all 100ms ease-in;
}
.uploader .icon {
  color: #eee;
  color: rgba(0, 0, 0, 0.2);
  font-size: 5em;
}
.uploader img {
  left: 50%;
  opacity: 0;
  max-height: 100%;
  max-width: 100%;
  position: absolute;
  top: 50%;
  -webkit-transition: all 300ms ease-in;
  -moz-transition: all 300ms ease-in;
  -ms-transition: all 300ms ease-in;
  -o-transition: all 300ms ease-in;
  transition: all 300ms ease-in;
  -webkit-transform: translate(-50%, -50%);
  -moz-transform: translate(-50%, -50%);
  -ms-transform: translate(-50%, -50%);
  -o-transform: translate(-50%, -50%);
  transform: translate(-50%, -50%);
}
.uploader img.loaded {
  opacity: 1;
}
.uploader-box > *:not(:last-child) {
  margin-bottom: 5px;
}
參考至 https://stackblitz.com/edit/angular-image-upload-base64?file=app%2Ffile-uploader.component.ts
參考至 https://medium.com/@amcdnl/file-uploads-with-angular-reactive-forms-960fd0b34cb5

dialog-detail.component.ts:export interface IDetailProduct {
  product: IProduct;
  types: IType[];
}
@Component(...))
export class DialogProductDetailComponent implements OnInit {
  PRODUCTSTATUS = PRODUCTSTATUS;
  form: FormGroup;
  constructor(
    public dialogRef: MatDialogRef<DialogProductDetailComponent>,
    @Inject(MAT_DIALOG_DATA) public data: IDetailProduct,
    private fb: FormBuilder
  ) {}
  ngOnInit() {
    if (!!this.data && !!this.data.types) {
      this.createForm();
      if (!!this.data.product) {
        this.editForm();
      }
    }
  }
  createForm() {
    let obj = {
      name: ["", [Validators.required]],
      price: [
        "", 
        [
          Validators.required, 
          ValidationService.integerValidator
        ]
      ],
      typeId: [
        "", 
        [
          Validators.required, 
          ValidationService.numberOnlyValidator
        ]
      ],
      status: ["", [Validators.required]],
      file: ["", [Validators.required]]
    };
    this.form = this.fb.group(obj);
  }
  editForm() {
    let product = this.data.product;
    this.form.patchValue({
      name: product.name || "",
      price: product.price.toString() || "",
      typeId: product.typeId.toString() || "",
      status: product.status.toString() || "",
      file: product.file || ""
    });
  }
  getDetailData(): IProduct {
    return <IProduct>{
      name: this.form.value.name,
      price: +this.form.value.price,
      typeId: +this.form.value.typeId,
      status: +this.form.value.status,
      file: this.form.value.file
    };
  }
  onNoClick(): void {
    this.dialogRef.close();
  }
  onEnter() {
    this.dialogRef.close(this.getDetailData());
  }
}
--
dialog-detail.component.html:<form [formGroup]="form">
  <div mat-dialog-title class="flex center">
    <mat-icon svgIcon="alert"></mat-icon>
    <span *ngIf="!data.product"> 
      {{ "alert_product_insert" | translate }} 
    </span>
    <span *ngIf="!!data.product"> 
      {{ "alert_product_update" | translate }} 
    </span>
  </div>
  <div mat-dialog-content>
    <div class="item-wrapper two pink">
      <div>
        <span> *{{ "name" | translate }} </span>
      </div>
      <div>
        <input
          type="text"
          formControlName="name"
          [placeholder]="'import_name' | translate"
          required
        />
      </div>
      <validation-messages [control]="form.controls.name">
      </validation-messages>
    </div>
    <div class="item-wrapper two pink">
      <div>
        <span> *{{ "product_price" | translate }} </span>
      </div>
      <div>
        <input
          type="text"
          formControlName="price"
          [placeholder]="'import_coin' | translate"
          required
        />
      </div>
      <validation-messages [control]="form.controls.price">
      </validation-messages>
    </div>
    <div class="item-wrapper two pink">
      <div>
        <span>*{{ "status" | translate }}</span>
      </div>
      <div>
        <select name="se_status" id="se_status"
         formControlName="status">
          <option value="" disabled>
            {{ "select" | translate }}
          </option>
          <option *ngFor="let status of PRODUCTSTATUS" 
          [value]="status.id">
          {{status.name | translate}}
          </option>
        </select>
      </div>
      <validation-messages [control]="form.controls.status">
      </validation-messages>
    </div>
    <div class="item-wrapper two pink">
      <div>
        <span>*{{ "product_type" | translate }}</span>
      </div>
      <div>
        <select name="se_type" id="se_type" formControlName="typeId">
          <option value="" disabled>{{ "select" | translate }}</option>
          <option *ngFor="let type of data.types"
           [value]="type.id">
            {{type.name | translate}}
           </option>
        </select>
      </div>
      <validation-messages [control]="form.controls.typeId">
      </validation-messages>
    </div>
    <file-uploader [parent]="form"></file-uploader>
  </div>
  <div mat-dialog-actions class="flex center">
    <button (click)="onNoClick()" class="button pb radius-5" 
    style="margin-right:10px;">
      {{ "cancel" | translate }}
    </button>
    <button
      (click)="onEnter()"
      [disabled]="!form.valid"
      [ngClass]="{ disable: !form.valid }"
      class="button pb pink radius-5"
    >
      {{ "enter" | translate }}
    </button>
  </div>
</form>
--
list.component.ts:export class ProductListComponent implements OnInit {
  ...
  types: IType[] = [];
  constructor(...) {}
  /*初始 */
  init() {
    if (!this.tab.pageObj) {
      this.tab.pageObj = <IPage>{
        pageIndex: 0,
        pageSize: PAGESIZE,
        length: 0
      };
    }
    this.setDatas(true);
    this.setTypes();
  }
  /*裝所有types資料 */
  setTypes() {
    let url = this.dataService.setUrl("types");
    this.dataService.getData(url)
    .subscribe((data: IData) => {
      if (!!data.errorcode) {
        this.openStatusDialog(data.errorcode);
      } else {
        if (!!data.res) {
          this.types = <IType[]>data.res;
        }
      }
    });
  }
  /*開Dialog */
  openDialog(action: string, select?: IProduct) {
    switch (action) {
      case "insert":
        this.openDetailDialog();
        break;
      case "update":
        this.openDetailDialog(select);
        break;
      case "type":
        this.openTypeDialog();
        break;
    }
  }
  /*開啟新增/更新的dialog */
  openDetailDialog(select?: IProduct) {
    if (!!this.types && !!this.types.length) {
      let obj = <IDetailProduct>{
        product: null,
        types: this.types
      };
      if (!!select) {
        obj.product = select;
      }
      let dialogRef = this.dialog
                      .open(DialogProductDetailComponent, {
                        width: "600px",
                        data: obj
                      });
      dialogRef.afterClosed().subscribe((o: IProduct) => {
        if (!!o) {
          if (!select) {
            this.insertProduct(o);
          } else {
            this.updateProduct(o, select);
          }
        }
      });
    }
  }
  /*新增商品 */
  insertProduct(o: IProduct) {
    let url = this.dataService.setUrl("products");
    o = <IProduct>this.dataService.checkData(
                    o, 
                    this.userService.getUser().id
                  );
    this.dataService.insertOne(url, o)
    .subscribe((data: IData) => {
      this.openStatusDialog(data.errorcode);
    });
  }
  /*修改商品 */ 
  updateProduct(o: IProduct, select: IProduct) {
    let url = this.dataService.setUrl(
                                  "products", 
                                  null, 
                                  select.id
                                );
    o = <IProduct>this.dataService.checkData(
                      o, 
                      this.userService.getUser().id, 
                      false
                    );
    this.dataService.updateOne(url, o)
    .subscribe((data: IData) => {
      this.openStatusDialog(data.errorcode);
    });
  }
  ...
  /*打開 alert-dialog 提示視窗 */
  openStatusDialog(errorcode: number) {
    let dialogRef = this.dialog.open(DialogAlertComponent, {
      width: "250px",
      data: {
        errorcode: errorcode
      }
    });
    dialogRef.afterClosed().subscribe(() => {
      this.setDatas();
    });
  }
}
如下圖中的縮圖

-src
  |-app
    |-cms
    |-modules
        |-...
        |-preview
            |-preview.component.css
            |-preview.component.html
            |-preview.component.ts
            |-preview.module.ts
preview.component.ts:@Component({
  selector: "app-preview",
  templateUrl: "preview.component.html",
  styleUrls: ["preview.component.css"]
})
export class PreviewComponent {
  @Input() imageSrc: string = "";
  imageLoaded: boolean = false;
  constructor() {}
  handleImageLoad() {
    this.imageLoaded = true;
  }
}
imageLoaded是指圖片是否已加載完。--
preview.component.html:<img *ngIf="imageSrc" [src]="imageSrc" (load)="handleImageLoad()" />
img {
  width: 25px;
  height: auto;
}
--
preview.module.ts:@NgModule({
  providers: [],
  imports: [CommonModule],
  declarations: [PreviewComponent],
  exports: [PreviewComponent]
})
export class PreviewModule {}
實際運用時:
<td [attr.data-title]="'product_img' | translate">
  <app-preview [imageSrc]="r.file"></app-preview>
</td>
此為完整專案範例碼,連線方式為json-server。
https://stackblitz.com/edit/ngcms-json-server
一開始會跳出提示視窗顯示fail為正常,
請先從範例專案裡下載或是複製db.json到本地端,
並下指令:
json-server db.json
json-server開啟成功後請連結此網址:
https://ngcms-json-server.stackblitz.io/cms?token=bc6e113d26ce620066237d5e43f14690